Skip to content

Bun.file reads: stop at the reader's own fstat size once the store is statted - #43909

Closed
robobun wants to merge 2 commits into
mainfrom
robobun/c897cd79/file-readers-own-fstat-bound
Closed

robobun wants to merge 2 commits into
mainfrom
robobun/c897cd79/file-readers-own-fstat-bound

Conversation

@robobun

@robobun robobun commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A Bun.file() read that knows the file size skips the last read() (0 bytes at EOF). Today only a read after .size or exists() knows it.
  • A read after .lastModified or Response.clone() has a statted store and no size on the blob. It pays that read(): 2.04 per text() of a 64 KiB file, not 1.03.
  • Bun.file: do not use a cached stat size as the byte budget of a read or a copy #43910 takes st_size off the blob (a procfs file reports 0 and has content). Every stat-first read would then pay it.

Fix

  • ReadFile, ReadFileUV and FileReader stop at the size from the fstat they run at open. On Windows FileReader asks the handle (GetFileSizeEx).
  • This applies only when the store was statted before the read. A size of 0 is never a bound.
  • Verified: test/js/bun/util/bun-file-read.test.ts counts read syscalls (/proc/self/io): 2 on main, 1 here. More in Notes.

Background

  • Every Blob over one Bun.file() shares a store that caches one stat. Only resolve_file_stat writes seekable, so seekable.is_some() means "statted".
  • Considered the cached size as the limit when it is not 0. It goes stale: exists(), a write, then text() stops at the old length (BunFile .text() does not return correct content after BunFile.write() #23902). A fresh fstat costs the same.

Downsides

  • A file that grows between the fstat and the last read() yields its fstat-time bytes for these reads. A read after .size already stops at an older size.
  • Windows: one GetFileSizeEx per stream of a statted store. POSIX: no new syscall.
Notes

read syscalls per operation, 64 KiB file, /proc/self/io (syscr) over 300 iterations, debug builds, Linux x64. Syscall counts do not depend on the build profile.

operation main this PR
fresh text() 2.03 2.04
.size, then text() 1.03 1.03
exists(), then text() 1.03 1.03
.lastModified, then text() 2.04 1.04
fresh stream() 2.10 2.10
.size, then stream() 1.09 1.09
.lastModified, then stream() 2.10 1.09
new Response(file).body 1.10 1.09

Windows x64, read operations per operation from Win32_Process.ReadOperationCount, same file, 300 iterations, debug builds: .lastModified then text() 2.01 to 1.01, .lastModified then stream() 2.01 to 1.01. Fresh reads stay at 2.01 and stat-first reads stay at 1.01.

The gate is file_store.seekable.is_some() on the File that the reader captured when it was created. A fresh Bun.file(p).text() keeps its behaviour: it reads until EOF, and it returns bytes that were appended after its fstat.

A file whose fstat under-reports a size above 0 is cut at that size for these reads, as .size then text() already is on main. A size of 0 (procfs, sysfs with no content length) is never a bound.

The plan for this stack called this PR a refactor with no behaviour change. That is not exact, because of the first Downsides line, so this PR has a test and does not claim it.

Suites run with a debug build, Linux: blob, body, body-clone, wasm-streaming, structured-clone-blob-file (1044 pass), streams, fetch-file-upload, bun-file-fd-read, empty-file (638 pass). fetch.test.ts, bun-file-exists, bun-file, bun-file-read, bun-serve-file, FormData: the same 21 failures by name as main (they need IPv6, the public internet, or a non-root user). Windows: 1719 pass in 13 suites. bun run rust:check-all passes on 12 targets.

… statted

A Bun.file() read that knows nothing about the file reads until read()
returns 0. That last call is an EOF probe: it returns no bytes. A read
after `.size` or `exists()` skips the probe, because the blob carries
the cached stat size as its limit.

A store can be statted while the size of the blob stays unknown:
`.lastModified`, `Response.clone()` and `.body` on another wrapper all do
that. Those reads paid the probe although the size of the file was known.

ReadFile, ReadFileUV and FileReader now bound such a read by the size
from the fstat that they already run at open. The bound applies only
when the store was statted before the read, and the fstat reports a
regular file with a size above 0. A size of 0 stays unbounded, because a
procfs file reports 0 and has content.

Read syscalls per operation on a 64 KiB file (/proc/self/io, debug build):
  .lastModified then text():     2.04 -> 1.04
  .lastModified then stream():   2.10 -> 1.09
  fresh text() and stream():     2.03 and 2.10, unchanged
  .size then text() and stream(): 1.03 and 1.09, unchanged
No fstat runs when FileReader opens a file on Windows, so the stream
reader had no size of its own there and could only stop at EOF. For a
store that was already statted as a regular file, ask the open handle
for its size (GetFileSizeEx: one call, no threadpool hop). The reader
then stops at that size, as it does on POSIX.
@robobun

robobun commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator Author

Status: draft. This is the second PR of a stack: #43897 (refactor, green), this PR, then #43910 (the fix).

What it changes, measured on Linux x64 with /proc/self/io (read syscalls per operation, 64 KiB file, 300 iterations):

const f = Bun.file(p);
f.lastModified;          // stats the store, the blob keeps no size
await f.text();          // main: 2.04 reads. here: 1.04

Fresh reads (2.03) and reads after .size or exists() (1.03) do not move. On Windows x64 the same case goes from 2 uv_fs_read calls to 1.

The new test in test/js/bun/util/bun-file-read.test.ts fails with src/ at main (2 reads per operation) and passes here (1).

It stays a draft until the review of the whole stack is done.

@robobun

robobun commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: this change is now part of #43910.

The reader bound moves the byte budget of a read from the cached stat to the fstat of the reader. #43910 removes the cached budget. The two are one change, and this half changes behaviour, so it is not groundwork that can merge alone.

#43910 has the same reader code with one difference: ReadFile and ReadFileUV share one helper. The measurements from this PR are in the Notes of #43910, from release builds. The branch stays, so this PR can reopen if the split is preferred.

@robobun robobun closed this Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant